Java 包装类相关的问题
自动装箱的原理
自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int
的变量转换成 Integer
对象,这个过程叫做装箱,反之将 Integer
对象转换成 int
类型值,这个过程叫做拆箱。
因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。
原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。
public class Main {
public static void main(String[] args) {
Integer i = 10;
int n = i;
}
}
反编译 class 文件之后得到如下内容:
从反编译得到的字节码内容可以看出,在装箱的时候自动调用的是 Integer 的 valueOf(int)
方法。
而在拆箱的时候自动调用的是 Integer 的 intValue 方法。
Integer a = 1000;
int b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: true
注意:当 "==" 运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)
所以当两个都是 Integer 类型时则不会发生拆箱,而是直接比对指针地址
Integer a = 1000;
Integer b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: false
String 直接赋值与 new String的区别
虽然它不是基本类型,但是也经常遇到坑,这里拿来一起说了
在研究 String 直接赋值与 new String 的区别之前我们需要先了解 Java 中的字符串常量池的概念
String 类是我们平常项目中使用频率非常高的一种对象类型,jvm 为了提升性能和减少内存开销,避免字符的重复创建,其维护了一块特殊的内存空间,即字符串池,当需要使用字符串时,先去字符串池中查看该字符串是否已经存在(通过比对 Hash),如果存在,则可以直接使用,如果不存在,初始化,并将该字符串放入字符创常量池中。
String str = "abc";
可能创建一个或者不创建对象,如果 "abc"
在字符串池中不存在,会在 Java 字符串池中创建一个 String 对象(abc
),然后 str 指向这个内存地址,无论以后用这种方式创建多少个值为 abc
的字符串对象,始终只有一个内存地址被分配。
注意:==
判断的是对象的内存地址,而 equals 判断的是对象内容。通过以下代码测试:
String str = "abc";
String str1 = "abc";
String str2 = "abc";
System.out.println(str==str1);//true
System.out.println(str==str2);//true
也就是 str、str1、str2 都是指向同一个内存地址。
String str = new String("abc");
至少会创建一个对象,也有可能创建两个。因为用到 new
关键字,肯定会在堆中创建一个 String 对象,如果字符池中已经存在 "abc"
,则不会在字符串池中创建一个 String 对象,如果不存在,则会在字符串常量池中也创建一个对象。
String str = new String("abc");
String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str==str1);//false
System.out.println(str==str2);//false
可以看出来,str、str1、str2 指向的是不同的内存地址
项目中除了直接使用 =
赋值,也会用到字符串拼接,字符串拼接又分为变量拼接和已知字符串拼接(这里细节看 JVM 字符常量池那里)
String str = "abc";//在常量池中创建abc
String str1 = "abcd";//在常量池中创建abcd
//拼接字符串,此时会在堆中新建一个abcd的对象,因为str2编译之前是未知的
String str2 = str + "d";
//拼接之后str3还是abcd,所以还是会指向字符串常量池的内存地址
String str3 = "abc" + "d";
System.out.println(str1 == str2);//false
System.out.println(str1 == str3);//true
String 类型的拆箱
String str = new String("hello");
System.out.println(str == "hello");
返回的是 false,因为 String 类型不是基本类型,所以不存在拆箱这一操作
Integer 包装类如何比较它们
在开发中我们经常会使用包装类(例如 Boolean, Double, 以及 Integer 等等)。
String one = "1";
Boolean b1 = Boolean.valueOf(one); // line n1
Integer i1 = new Integer(one);
Integer i2 = 1;
if (b1) {
System.out.print(i1 == i2);
}
执行结果是什么,请选择:
- A、 抛出运行时异常
- B、 true
- C、 false
- D、 无任何输出
这个问题考察原生数据的包装类(primitive wrapper),主要是 Boolean 类比较生僻的 valueOf 工厂方法。
包装类主要提供了三种获取对象实例的方法:
1、每个包装类都有名为 valueOf 的静态工厂方法。
2、如果语义很清晰,在代码中将原生数据类型赋值给包装类的变量,则会发生自动装箱 (autoboxing)。 自动装箱只是语法上的简写,它允许编译器 (javac) 自动调用 valueOf 方法,目的是为了编码更简洁。
3、第三种方法是使用构造器,也就是通过 new
关键字来调用构造函数。 实际上,在 Java 9 中已经不推荐使用第三种方法。
为什么不应该使用 new
在 Java 中,只要使用 new
关键字调用构造函数,只会发生两种情况: 要么成功创建指定类型的新对象并返回,要么就抛异常。
这实际上是一个限制,如今一般是推荐使用工厂方法,因为工厂方法除了达成构造函数的效果之外,还会有一些优化。
工厂方法的有些功能是用构造函数实现不了的:比如返回与请求参数相匹配的已缓存的实例对象(例如字符常量池)
这种行为类似于在编码中直接使用 "XXX"
这种字面量表示方式, 而不是 new String("XXX")
。
因为 Integer 包装器是不可变的,表示相同数值的两个 Integer 对象一般是可以互换的。
因此,创建多个表示相同值的对象实例会浪费内存。
很多情况下,工厂方法返回的两个对象允许使用 ==
来比较, 而不必每次都写成 equals(Object o)
这种方式。
对于 Integer 类来说,一般只缓存了 -128 到 +127 范围内的值。(具体看下面缓存那节)
工厂方法的返回值
先来看下,使用工厂方法的两个好处:
- 如果有多个工厂方法,则每个方法都可以使用不同的名称,因为名称不同,也就可以使用相同的入参声明。
- 对于构造函数而言,因为必须参数类型不同才能形成重载,也就不可能根据同样的参数构造不同的对象。
在 Java 中用 new
调用构造函数只能返回固定类型的对象。
而用工厂方法则可以返回兼容的各种类型对象实例(例如接口的实现类,而且这是一种隐藏实现细节的绝佳方法)。
回到这个问题,最关键的地方在于,我们使用 Boolean.valueOf(...)
方法时,只会得到两个常量对象: Boolean.TRUE
和 Boolean.FALSE
。
这两个对象可以被重复利用,不会浪费多余的内存。 如果使用 new
调用显然是不可能的
大部分包装类的工厂方法,如果传入了 null 参数,或者字符串参数不符合目标值的表现形式就会抛出异常,例如,Integer.valueOf("six")
就会抛异常。
但 java.lang.Boolean
类的工厂方法是个特例, 内部实现判断的是非空(null)并且等于 “true”(忽略大小写)。
String one = "1";
Boolean b1 = Boolean.valueOf(one); // 这里实际上是 False
内部实现如下所示:
public static boolean parseBoolean(String s) {
return ((s != null) && s.equalsIgnoreCase("true"));
}
- 如果满足这两个条件则返回
Boolean.TRUE
。 - 否则直接返回
Boolean.FALSE
。
这意味着: 如果传入 null
或者无意义的字符串,则会返回 Boolean.FALSE
,并不会抛出异常。
基于这点,我们可以确定 n1 行那里不会抛出异常,而是返回 Boolean.FALSE
,被赋值给变量 b1。
因此,可以确定 选项A 不正确。
两种形式的比较
- 第一种是
==
运算符,是 Java 语法的一部分。 - 第二种是
equals(Object o)
方法,本质上是一个 API。
每个对象都可以使用 equals(Object o)
方法,因为这个方法是在 java.lang.Object
类中定义的。
==
运算符比较两个表达式的值。
听起来很简单,但是表达式的值可能有两种不同的类型。这两种类型使用 ==
的结果可能会不同。
表达式主要有两种类型:
- 原生数据类型 / 基本数据类型 (primitive,共8种: boolean, byte, short, char, int, long, float, double)
- 引用类型(reference),引用类似于指针,表示内存中某个对象的地址值(可以认为是一个偏移量数值)。
如果表达式是原生数据类型,则表达式的值很直观。 例如,如果 int 表达式的值为 32,则该表达式的值就是 32 的二进制表示形式。
但问题是,如果变量是引用类型呢?(例如,Integer 类型)
它所引用对象内部的值为32,这个引用的值 并不是 32。而是一个神秘的数字(引用地址),通过这个引用地址,JVM 可以找到对应的 Integer 对象
也就是说,对于引用类型(即除了 8 种原生数据类型之外的所有类型),==
表达式判断的是这两个引用的内存地址值是否相等,即判断它们是否引用了同一个对象。
最重要的是,即使两个 Integer 对象里面的值都是 32,但如果它们是不同的对象, 那么它们的引用地址也就不同,使用 ==
比较会返回 false。
如下所示:
Integer v1 = new Integer("1");
Integer v2 = new Integer("1");
System.out.print(v1 == v2);
这里的输出肯定是 false
。
前面提到过,new
关键字的任何调用,要么产生一个新对象,要么抛异常。这意味着 v2 和 v1 引用了不同的对象,==
操作的结果为 false
。
换一种方式,如果有以下代码:
Integer v1 = new Integer("1");
Integer v2 = 1;
System.out.print(v1 == v2);
这与面试题中的代码很像,一个使用构造函数, 一个使用自动装箱,可以肯定这也会输出 false
。因为构造函数创建的对象必定是唯一的新对象,因此,不可能 ==
自动装箱为工厂方法返回的对象。
Integer 的缓存 “坑”
而不可变对象的工厂方法一般都会有特殊处理,只要在一个范围内,并且参数相等,就返回同一个(缓存的)对象。
Integer 类的 API文档中,对 valueOf(int)
方法有如下说明:
“此方法将始终缓存 [-128 ~ 127] 范围内的值, 可能还会缓存这个范围之外的其他值。”
@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
所以对以下代码:
Integer v1 = Integer.valueOf(1);
Integer v2 = Integer.valueOf(1);
System.out.print(v1 == v2);
上面这段代码肯定会输出 true
。
虽然只在 valueOf(int)
和 valueOf(String)
方法的文档说明中提到了这个缓存保证。
但在实际的实现中, 其他包装类也表现出相同的缓存行为。
上面也说了,它只缓存 [-128 ~ 127] 范围内的值,大于这个值则是不同的
Integer a = Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);
System.out.println("return: " + (a == b)); // 输出为:return: false
同样,直接赋值给包装类实际还是调用了 valueOf
方法,因此
Integer a = 1000;
Integer b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: false
但是如果和 int
类型比较时则会发生自动拆箱
Integer a = 1000;
int b = 1000;
System.out.println("return: " + (a == b)); // 输出为:return: true
注意:当 "==" 运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程),所以包装类尽量使用 equals 方法来判断是否相等
补充:为什么 Java 只有值传递
首先,我们回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。
- 按值调用(call by value)表示方法接收的是调用者提供的值,
- 按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。
一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。它用来描述各种程序设计语言(不只是 Java)中方法参数传递方式。
Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,也就是说,方法不能修改传递给它的任何参数变量的内容。
Reference
参考资料 Java坑人面试题系列: 包装类(中级难度) 参考资料 深入剖析Java中的装箱和拆箱